/*
 * Copyright © 2017 eqxiu.com 北京中网易企秀科技有限公司  All rights reserved.
 */
package cn.hermit.data.xml;

import cn.hermit.core.BackableReader;
import cn.hermit.data.xml.factory.OrderedXmlFactory;
import cn.hermit.data.xml.model.XmlNameNode;
import cn.hermit.data.xml.validator.Validator;
import cn.hermit.util.StringUtils;
import cn.hermit.data.exception.ParserException;
import cn.hermit.data.xml.handler.ContentHandler;
import cn.hermit.data.xml.handler.XmlHandler;
import cn.hermit.data.xml.model.XmlDocument;
import cn.hermit.data.xml.model.XmlFactory;
import cn.hermit.data.xml.ns.NamespaceContext;
import cn.hermit.data.xml.ns.QName;

import javax.xml.XMLConstants;
import java.io.IOException;
import java.io.Reader;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * XML Parser uses <i>Deterministic finite automaton</i> theory for parsing XML string.
 * 
 * <p>
 * XML Parser provides a lazy singleton pattern to getHttpRequest the instance.
 * It also provides new instance method to avoid some STATEful operations' conflicting.
 * </p>
 * 
 * <p>
 * It supports streaming API with {@link ContentHandler SAX syntax}.
 * And also, it can generate a document tree of XML with {@link XmlHandler XmlHandler}.
 * Please see the method {@link XMLParser#parse(Reader, ContentHandler) parse(Reader, ContentHandler)}
 * </p>
 * 
 * <p>
 * Some cases requires to keep order of XML nodes or XML attributes.
 * The framework provides many factories to create various kinds of XML element, which keeps nodes or attributes order.
 * Please see the abstract class {@link XmlFactory XmlFactory}
 * and the package of {@link com.eqxiu.tiny.data.xml.factory various factories }
 * </p>
 * 
 * <p>
 * Sometimes, XML string need to be validated or the characters in 
 * XML string need to be adjusted for some use case.
 * {@link Validator Validator} provides methods to validate tags and texts.
 * The <tt>Validator</tt> also provide a chain to for a serious of validation.
 * </p>
 * 
 * <p>
 * The framework provides {@link com.eqxiu.tiny.data.log.Logger a simple logger}
 * which can adapt to third party logging system by {@link com.eqxiu.tiny.data.log.Logger#setDelegator(com.eqxiu.tiny.data.log.LogDelegator) setDelegator(LogDelegator)}.<br/>
 * </p>
 * 
 * @author Jack Gao (Chinese name : GAO JIANGUO, Email : linux.gjg@gmail.com)
 * @date 19 Jun, 2014
 */
public class XmlParser implements XML {

    private static XmlParser INSTANCE;

    private XmlParser() {

    }

    /**
     * Lazy singleton pattern.
     * 
     * <p>
     * The singleton instance is treated as a tool to handle none context or stateful less
     * parsing.
     * </p>
     * 
     * @return
     * 		The singleton instance of XML Parser.
     */
    public static XmlParser getInstance() {
        if (INSTANCE == null) {
            synchronized (XmlParser.class) {
                if (INSTANCE == null) {
                    INSTANCE = new XmlParser();
                }
            }
        }
        return INSTANCE;
    }

    /**
     * Create a new instance of XML Parser.
     * <p>Provide a consistent API to create a instance</p>
     * 
     * @return
     * 		New instance of XML Parser.
     */
    public static XmlParser newInstance() {
        return new XmlParser();
    }

    /**
     * Parse XML string with a reader.
     * 
     * @param reader
     * 			Reader of XML string.
     * @return
     * @throws ParserException
     */
    public XmlDocument parse(Reader reader) throws ParserException {
        XmlHandler handler = new XmlHandler();
        parse(reader, handler);
        return handler.getDocument();
    }

    /**
     * Parse XML string with a reader and XmlFactory.
     * 
     * @see XmlFactory XmlFactory
     * @see com.eqxiu.tiny.data.xml.factory.HashJSONFactory HashXmlFactory
     * @see OrderedXmlFactory OrderedXmlFactory
     * 
     * @param reader
     * 		Reader of XML string
     * @param xmlFactory
     * 		XmlFactory to create XML Object
     * @return
     * 		Document of XML 
     * @throws ParserException
     */
    public XmlDocument parse(Reader reader, XmlFactory xmlFactory) throws ParserException {
        XmlHandler handler = new XmlHandler(xmlFactory);
        parse(reader, handler);
        return handler.getDocument();
    }

    /**
     * Parse XML string with a reader and ContentHandler
     * 
     * <p>
     * It provides a streaming API with ContentHandler in SAX syntax.
     * </p>
     * 
     * @param reader
     * 		Reader of XML string
     * @param handler
     * 		ContentHandler in SAX syntax
     * @throws ParserException
     */
    public void parse(Reader reader, ContentHandler handler) throws ParserException {
        BackableReader br = new BackableReader(reader);
        NamespaceContext nsContext = new NamespaceContext();
        parse(STATUS_XML, br, handler, nsContext);
        nsContext.destory();
    }

    /*
     * @param status
     * @param br
     * @param handler
     * @param nsConext
     *            namespace context
     * @throws ParserException
     */
    private void parse(byte status, BackableReader br, ContentHandler handler, NamespaceContext nsContext)
            throws ParserException {
        try {
            if (status == XML.STATUS_XML) {
                handler.startDocument();
            }
            String tmpTag = null;
            QName tmpName = null;
            boolean hasNSDef = false;
            String temTextInBody = "";
            Map<String, String> tmpAttributes = new LinkedHashMap<String, String>();
            boolean handledSubElement = false;
            while (true) {
                String token = br.read(TOKEN_DELIMS);
                if (StringUtils.isEmpty(token)) {
                    if (status == XML.STATUS_XML) {
                        handler.endDocument();
                    } else {
                        invalidXML(br);
                    }
                    return;
                }
                char delim = token.charAt(token.length() - 1);
                if (token.length() > 1) {
                    token = token.substring(0, token.length() - 1);
                } else {
                    token = null;
                }
                switch (delim) {
                case '<':
                    int b = br.read();
                    if (b <= ' ') {
                        invalidXML(br);
                    }
                    br.back();
                    if (status == XML.STATUS_BODY) {
                        if (b == '/') {
                            if (handledSubElement) {
                                if (!StringUtils.isEmpty(token)) {
                                    // handle mixed text
                                    if (!StringUtils.isEmpty(temTextInBody)) {
                                        token = temTextInBody + token;
                                    }
                                    handler.text(token.trim());
                                }
                            } else {
                                // handle text
                                if (token != null) {
                                    if (!StringUtils.isEmpty(temTextInBody)) {
                                        token = temTextInBody + token;
                                    }
                                    if ("".equals(token.trim())) {
                                        handler.text(token);
                                    } else {
                                        handler.text(token.trim());
                                    }
                                }
                            }
                            status = XML.STATUS_TAG;
                        } else {
                            if (!StringUtils.isEmpty(token)) {
                                // handle mixed text
                                if (!StringUtils.isEmpty(temTextInBody)) {
                                    token = temTextInBody + token;
                                }
                                handler.text(token.trim());
                            }
                            parse(XML.STATUS_TAG, br, handler, nsContext);
                            handledSubElement = true;
                        }
                        temTextInBody = "";
                    } else if (status == XML.STATUS_XML) {
                        if (b == '/') {
                            invalidXML(br);
                        } else {
                            parse(XML.STATUS_TAG, br, handler, nsContext);
                            handledSubElement = true;
                        }
                    } else {
                        invalidXML(br);
                    }
                    break;
                case '!':
                    if (status == XML.STATUS_TAG) {
                        if (token != null) {
                            invalidXML(br);
                        }
                        handleCDATAOrComment(br, handler);
                        return;
                    } else if (status == XML.STATUS_BODY) {
                        if (token != null)
                            temTextInBody += token;
                        temTextInBody += delim;
                    } else {
                        invalidXML(br);
                    }
                    break;
                case '?':
                    if (status == XML.STATUS_TAG) {
                        if (token != null) {
                            invalidXML(br);
                        }
                        handleHeaders(br, handler);
                        return;
                    } else if (status == XML.STATUS_BODY) {
                        if (token != null)
                            temTextInBody += token;
                        temTextInBody += delim;
                    } else {
                        invalidXML(br);
                    }
                    break;
                case '=':
                    if (status == XML.STATUS_TAG) {
                        int index = -1;
                        if (StringUtils.isEmpty(token) || (index = token.indexOf(' ')) == -1) {
                            invalidXML(br);
                        }
                        tmpTag = token.substring(0, index);
                        br.back(token.length() - index);
                        hasNSDef = populateAttribute(br, tmpAttributes, nsContext);
                    } else if (status == XML.STATUS_BODY) {
                        if (token != null)
                            temTextInBody += token;
                        temTextInBody += delim;
                    } else {
                        invalidXML(br);
                    }
                    break;
                case '>':
                    if (status == XML.STATUS_TAG) {
                        if (tmpTag == null) {
                            if (StringUtils.isEmpty(token) || !token.equals(token.trim())) {
                                invalidXML(br);
                            }
                            tmpTag = token;
                        }
                        tmpName = nsContext.getQName(tmpTag);
                        if (tmpName == null) {
                            this.error(
                                "Invalid tag[" + tmpTag + "], no such prefix defined at[{0}]",
                                br.getCacheString());
                        }
                        handler.startElement(tmpName, false);
                        handleAttributes(nsContext, tmpAttributes, br, handler);
                        tmpAttributes.clear();
                    } else {
                        invalidXML(br);
                    }
                    status = XML.STATUS_BODY;
                    break;
                case '/':
                    if (status == XML.STATUS_TAG) {
                        if (tmpTag == null) {
                            // <a/>
                            if (StringUtils.isEmpty(token) || !">".equals(br.read('>'))) {
                                invalidXML(br);
                            }
                            tmpTag = token.trim();
                            tmpName = nsContext.getQName(tmpTag);
                            if (tmpName == null) {
                                this.error(
                                    "Invalid tag[" + tmpTag + "], no such prefix defined at[{0}]",
                                    br.getCacheString());
                            }
                            handler.startElement(tmpName, true);
                            handler.endElement(tmpName, true);
                            if (hasNSDef) {
                                nsContext.unloadNamespaces(tmpName);
                            }
                        } else {
                            // <a x="y"/> or </a>
                            if (tmpAttributes.size() > 0) {
                                // <a x="y"/>
                                if (!StringUtils.isEmpty(token) || !">".equals(br.read('>'))) {
                                    invalidXML(br);
                                }
                                tmpName = nsContext.getQName(tmpTag);
                                if (tmpName == null || !XmlNameNode.getName(tmpName).equals(tmpTag)) {
                                    this.error(
                                        "Invalid tag[" + tmpTag + "], no such prefix defined at[{0}]",
                                        br.getCacheString());
                                }
                                handler.startElement(tmpName, true);
                                handleAttributes(nsContext, tmpAttributes, br, handler);
                                handler.endElement(tmpName, true);
                            } else {
                                // </a>
                                token = br.read('>');
                                if (StringUtils.isEmpty(token)
                                        || token.charAt(token.length() - 1) != '>'
                                        || !tmpTag.equals(token.substring(0, token.length() - 1))) {
                                    invalidXML(br);
                                }
                                // tmpName = nsContext.getQName(tmpTag);
                                if (tmpName == null || !XmlNameNode.getName(tmpName).equals(tmpTag)) {
                                    this.error(
                                        "Invalid tag[" + tmpTag + "], no such prefix defined at[{0}]",
                                        br.getCacheString());
                                }
                                handler.endElement(tmpName, false);
                            }
                            if (hasNSDef) {
                                nsContext.unloadNamespaces(tmpName);
                            }
                            tmpTag = null;
                            tmpAttributes.clear();
                        }
                        return;
                    } else if (status == XML.STATUS_BODY) {
                        if (token != null)
                            temTextInBody += token;
                        temTextInBody += delim;
                    } else {
                        invalidXML(br);
                    }
                    break;
                default:
                    invalidXML(br);
                    break;
                }
            }
        } catch (ParserException e) {
            throw e;
        } catch (Exception e) {
            error(e, br);
            throw new ParserException(e);
        }
    }

    private void handleAttributes(
            NamespaceContext nsContext,
            Map<String, String> attributes,
            BackableReader br,
            ContentHandler handler) throws IOException, ParserException {
        if (attributes.size() == 0) {
            return;
        }
        for (String key : attributes.keySet()) {
            QName name = nsContext.getQName(key);
            if (name == null) {
                this.error("Invalid key[" + key + "], no such prefix defined at[{0}]", br.getCacheString());
            }
            handler.attribute(nsContext.getQName(key), attributes.get(key));
        }
    }

    private void handleCDATAOrComment(BackableReader br, ContentHandler handler) throws IOException, ParserException {
        String token = br.read("[-");
        if ("[".equals(token)) {
            token = br.read("[");
            if (StringUtils.isEmpty(token) || token.charAt(token.length() - 1) != '[') {
                invalidXML(br);
            }
            String type = token.substring(0, token.length() - 1);
            if (!type.equals(type.trim())) {
                invalidXML(br);
            }
            boolean isCData = "CDATA".equals(token.substring(0, token.length() - 1));
            String content = br.readEndWith("]]>");
            if (StringUtils.isEmpty(content) || !content.endsWith("]]>")) {
                invalidXML(br);
            }
            if (isCData) {
                handler.cdata(content.substring(0, content.length() - 3));
            }
        } else if ("-".equals(token)) {
            int b = br.read();
            if (b != '-') {
                invalidXML(br);
            }
            String content = br.readEndWith("-->");
            if (StringUtils.isEmpty(content) || !content.endsWith("-->")) {
                invalidXML(br);
            }
            handler.comment(content.substring(0, content.length() - 3));
        } else {
            invalidXML(br);
        }
    }

    private void handleHeaders(BackableReader br, ContentHandler handler) throws IOException, ParserException {
        while (true) {
            String equal = br.read("=?");
            if (StringUtils.isEmpty(equal)) {
                invalidXML(br);
            }
            char eq = equal.charAt(equal.length() - 1);
            if (eq == '?') {
                int b = br.read();
                if (b != '>') {
                    invalidXML(br);
                }
                return;
            }
            if (eq != '=') {
                invalidXML(br);
            }
            String key = equal.substring(0, equal.length() - 1).trim();
            int index = key.indexOf(' ');
            if (index != -1) {
                String processingInstruction = key.substring(0, index);
                if ("xml".equals(processingInstruction)) {
                    // It's the header
                    key = key.substring(index).trim();
                } else {
                    String token = br.read('>');
                    if (token == null) {
                        invalidXML(br);
                    }
                    return;
                }
            }
            if (StringUtils.isEmpty(key)) {
                invalidXML(br);
            }
            String beforeVal = br.read("'\"");
            if (StringUtils.isEmpty(beforeVal)) {
                invalidXML(br);
            }
            char quote = beforeVal.charAt(beforeVal.length() - 1);
            if (quote != '"' && quote != '\'') {
                invalidXML(br);
            }
            String value = br.readInQuotes(quote);
            if (value == null) {
                invalidXML(br);
            }
            if ("version".equalsIgnoreCase(key)) {
                handler.version(value);
            } else if ("encoding".equalsIgnoreCase(key)) {
                handler.encoding(value);
            } else if ("standalone".equalsIgnoreCase(key)) {
                handler.standalone(value);
            } else {
                invalidXML(br);
            }
        }
    }

    private boolean populateAttribute(BackableReader br, Map<String, String> tmpAttributes, NamespaceContext nsContext)
            throws IOException,
            ParserException {
        boolean hasNSDef = false;
        while (true) {
            String equal = br.read("=/>");
            if (StringUtils.isEmpty(equal)) {
                invalidXML(br);
            }
            char eq = equal.charAt(equal.length() - 1);
            if (eq == '/' || eq == '>') {
                br.back();
                return hasNSDef;
            }
            if (eq != '=') {
                invalidXML(br);
            }
            String key = equal.substring(0, equal.length() - 1).trim();
            if (StringUtils.isEmpty(key)) {
                invalidXML(br);
            }
            String beforeVal = br.read("'\"");
            if (StringUtils.isEmpty(beforeVal)) {
                invalidXML(br);
            }
            char quote = beforeVal.charAt(beforeVal.length() - 1);
            if (quote != '"' && quote != '\'') {
                invalidXML(br);
            }
            String value = br.readInQuotes(quote);
            if (value == null) {
                invalidXML(br);
            }
            if (key.startsWith(XMLConstants.XMLNS_ATTRIBUTE)) {
                nsContext.loadNamespace(key, value, !hasNSDef);
                hasNSDef = true;
            }
            tmpAttributes.put(key, value);
        }
    }

    private void invalidXML(BackableReader br) throws ParserException {
        error("Invalid XML at[{0}]", br.getCacheString());
    }

    private void error(Exception e, BackableReader br) throws ParserException {
        String errorMsg = StringUtils.errorMessage(e);
        error("Error happened at[{0}]\n" + errorMsg, br.getCacheString());
    }

    private void error(String errorMsg, String... params) throws ParserException {
        throw new ParserException(StringUtils.mergeParams(errorMsg, params));
    }

}